package org.pitest.ant;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.taskdefs.Java;
import org.apache.tools.ant.types.Commandline;
import org.apache.tools.ant.types.Path;
import org.codehaus.plexus.util.StringOutputStream;
import org.pitest.ant.util.PathUtils;
import org.pitest.mutationtest.MutationCoverageReport;
import org.pitest.mutationtest.config.ConfigOption;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * A simple ant task for running the Pitest tool from an Apache ant task.
 * <p/>
 * This ant task provides the ability to run the Pitest tool as if it was launched using the command line interface,
 * just providing a simple wrapper to make it easier to call from ant.
 * <p/>
 * For details about the arguments to this task, see the command line reference <pre>http://pitest.org/quickstart/commandline/</pre>
 *
 * @author Aidan Morgan
 */
public class PitestAntTask extends Task {
  private static Logger s_log = Logger.getLogger(PitestAntTask.class.getName());

  /**
   * The file format constant for generating XML reports.
   */
  public static final String XML_FORMAT = "XML";

  /**
   * The file format constant for generating HTML reports.
   */
  public static final String HTML_FORMAT = "HTML";

  /**
   * The file format constant for generating CSV reports.
   */
  public static final String CSV_FORMAT = "CSV";


  private boolean mutateStaticInitializers = ConfigOption.MUTATE_STATIC_INITIALIZERS.getDefault(Boolean.class);

  /**
   * Helper method that will take in a directory represented by te provided String and make sure it exists and
   * can be written to.
   * <p/>
   * If the directory does not exist then it will be created. If the creation fails a {@see BuildException} will
   * be thrown.
   * <p/>
   * If the firectory does exist, but cannot be written to then a {@see BuildException} will be thrown.
   * <p/>
   * If the directory does exist (or could be created) and can be written to, then a {@see File} representation
   * of the directory will be returned.
   *
   * @param s the directory to validate, represented as a String.
   * @return the {@see File} instance of the provided directory if it is valid.
   * @throws BuildException if the directory could not be created, or the directory cannot be written to.
   */
  public static File validateDirectoryForWrite(String s) {
    File f = new File(s);

    if (!f.exists()) {
      if (!f.mkdirs()) {
        throw new BuildException("Could not create directory " + f.getAbsolutePath() + ".");
      }
    }

    if (!f.canWrite()) {
      throw new BuildException("Cannot use directory " + f.getAbsolutePath() + " as it cannot be used for writing to.");
    }

    return f;
  }

  private static void appendProperty(PrintWriter pw, ConfigOption option, String value) {
    pw.println("<property name=\"" + option.getParamName() + "\" value=\"" + value + "\"/>");
  }

  private static void appendFilter(PrintWriter pw, ConfigOption option, List<String> values) {
    pw.println("<" + option.getParamName() + ">");

    for (String s : values) {
      pw.println("<filter name=\"" + s + "\"/>");
    }

    pw.println("</" + option.getParamName() + ">");
  }

  /**
   * The number of threads pitest should use when mutating tests.
   * <b>Default value is Runtime.getNumberOfProcessors() - 1</b>
   */
  private int threads;

  /**
   * If set to {@code true} then the project file should be kept after {@code pitest} has been run.
   */
  private boolean keepProjectFile = false;

  /**
   * The name of the project file to generate from this task. Default value will be deleted after pitest has been run.
   */
  private String projectFileName = "pitest-ant-" + System.currentTimeMillis() + ".properties";

  /**
   * The directory (as a string) to publish pitest reports to.
   */
  private String reportDir;

  /**
   * Whether or not to consider classes in jar files for mutations and as a source of tests.
   */
  private boolean includeJarFiles = false;

  /**
   * If specified, contains a list of {@see TargetTest} instances that are filters to match for tests to mutate.
   */
  private TargetTests targetTests;

  /**
   * If specified, contains a list of {@see InScopeClass} instances.
   */
  private InScopeClasses inScopeClasses;

  /**
   * If specified, contains a list of {@see TargetClass} instances, which are the classes to match for mutation.
   */
  private TargetClasses targetClasses;

  /**
   * If specified, contains a list of {@see ExcludedMethod} instances, which are filters for methods to not mutate.
   */
  private ExcludedMethods excludedMethods;

  /**
   * If specified, contains a list of {@see ExcludedClass} instances, which are filters for classes to not mutate.
   */
  private ExcludedClasses excludedClasses;

  /**
   * If specified, allows additional {@see Path} instances to be defined for the classpath for running tests.
   */
  private Classpath classpath;

  /**
   * If specified, contains a list of {@see SourceDir} instances which allow the directories where source code is located to be specified.
   */
  private SourceDirs sourceDirectories;

  /**
   * The {@see Mutators} instance that allows the pitest mutators to be customised for the project.
   */
  private Mutators mutators;

  /**
   * A factor to apply to the normal runtime of a test when considerin if it is stuck in an infinite loop.
   */
  private float timeoutFactor = ConfigOption.TIMEOUT_FACTOR.getDefault(Float.class);

  /**
   * Constant amount of additional time to allow a test to run for (after the application of the timeoutFactor) before considering it to be stuck in an infinite loop.
   */
  private long timeoutConst = ConfigOption.TIMEOUT_CONST.getDefault(Long.class);

  /**
   * The maximum number of mutations to create per class. Use 0 or -ve number to set no limit.
   */
  private int maxMutationsPerClass = ConfigOption.MAX_MUTATIONS_PER_CLASS.getDefault(Integer.class);

  /**
   * PIT can optionally apply an additional filter to the supplied tests, such that only tests a certain distance from a mutated class will be considered for running. e.g A test that directly calls a method on a mutated class has a distance of 0 , a test that calls a method on a class that uses the mutee as an implementation detail has a distance of 1 etc.
   * <p/>
   * This filter will not work for tests that utilise classes via reflection or other methods where the dependencies between classes cannot be determined from the byte code.
   * <p/>
   * The distance filter is particularly useful when performing a targeted mutation test of a subset of classes within a large project as it avoids the overheads of calculating the times and coverage of tests that cannot exercise the mutees.
   */
  private int dependencyDistance = ConfigOption.DEPENDENCY_DISTANCE.getDefault(Integer.class);

  /**
   * Argument string to use when PIT launches child processes. This is most commonly used to increase the amount of memory available to the process, but may be used to pass any valid JVM argument.
   */
  private String jvmArgs;

  /**
   * A {@see List} of strings that represent the output formats to use. Is not modified directly, use the
   * {@see setXmlFormat}, {@see setCsvFormat} and {@see setHtmlFormat} methods instead.
   */
  private List<String> outputFormats;

  /**
   * Constructor.
   * Creates a new {@see PitestAntTask} and assigns default values wherever possible.
   */
  public PitestAntTask() {
    this.outputFormats = new ArrayList<String>();

    // default to use the number of processors minus one.
    threads = Math.max(Runtime.getRuntime().availableProcessors() - 1, 1);
    includeJarFiles = false;
    reportDir = ".";
    mutators = new Mutators(this);
  }

  /**
   * Sets the output directory for reports to the provided value.
   *
   * @param reportDir the directory to output reports to.
   * @throws IllegalArgumentException if the provided directory is null.
   */
  public void setReportDir(String reportDir) {
    this.reportDir = reportDir;
  }

  /**
   * Sets the number of threads that pitest should use to the provided value.
   *
   * @param threads the number of threads.
   * @throws IllegalArgumentException if the number of threads is less than one.
   */
  public void setThreads(int threads) {
    if (threads < 1) {
      throw new IllegalArgumentException("Cannot specify less than one thread for Pitest.");
    }

    this.threads = threads;
  }

  /**
   * Whether or not to consider classes in jar files for mutations and as a source of tests.
   *
   * @param includeJarFiles {@code true} if classes in jar files should be included, {@code false} otherwise.
   */
  public void setIncludeJarFiles(boolean includeJarFiles) {
    this.includeJarFiles = includeJarFiles;
  }

  /**
   * A factor to apply to the normal runtime of a test when considering if it is stuck in an infinite loop.
   *
   * @param timeoutFactor the factor to use
   * @throws IllegalArgumentException if the factor is less than {@code 1.0}.
   */
  public void setTimeoutFactor(float timeoutFactor) {
    if (timeoutFactor < 1.0) {
      throw new IllegalArgumentException("Must specify a timeout factor greater than or equal to 1.");
    }

    this.timeoutFactor = timeoutFactor;
  }

  /**
   * Constant amount of additional time to allow a test to run for (after the application of the timeoutFactor) before considering it to be stuck in an infinite loop.
   * <p/>
   * Defaults to 3000
   *
   * @param timeoutConst the timeout constant to use.
   * @throws IllegalArgumentException if the const is less than zero.
   */
  public void setTimeoutConst(long timeoutConst) {
    if (timeoutConst < 0) {
      throw new IllegalArgumentException("Must specify a timeout constant greater than zero.");
    }

    this.timeoutConst = timeoutConst;
  }

  /**
   * Sets the number of maximum mutations to use per class.
   * Use {@code 0} or a negative number to leave unrestricted.
   *
   * @param maxMutationsPerClass the number to use.
   */
  public void setMaxMutationsPerClass(int maxMutationsPerClass) {
    this.maxMutationsPerClass = maxMutationsPerClass;
  }

  public void setDependencyDistance(int dependencyDistance) {
    if (dependencyDistance < 0) {
      throw new IllegalArgumentException("Cannot specify a dependency distance of less than zero.");
    }

    this.dependencyDistance = dependencyDistance;
  }

  public void setMutateStaticInitializers(boolean mutateStaticInitializers) {
    this.mutateStaticInitializers = mutateStaticInitializers;
  }

  /**
   * Specifies the jvmargs to be used by the pitest framework.
   *
   * @param jvmArgs the jvmargs to be used by the pitest framework.
   * @throws IllegalArgumentException if the jvmargs is null.
   */
  public void setJvmArgs(String jvmArgs) {
    if (jvmArgs == null) {
      throw new IllegalArgumentException("Cannot specify a null jvm args value.");
    }

    this.jvmArgs = jvmArgs;
  }

  /**
   * Sets the generation of XML format reports.
   *
   * @param b enables ({@code true}, or disables ({@code false} the generation of XML format reports.
   */
  public void setXmlFormat(boolean b) {
    if (b) {
      outputFormats.add(XML_FORMAT);
    } else {
      outputFormats.remove(XML_FORMAT);
    }
  }

  /**
   * Sets the generation of CSV format reports.
   *
   * @param b enables ({@code true}, or disables ({@code false} the generation of CSV format reports.
   */
  public void setCsvFormat(boolean b) {
    if (b) {
      outputFormats.add(CSV_FORMAT);
    } else {
      outputFormats.remove(CSV_FORMAT);
    }
  }

  /**
   * Sets the generation of HTML format reports.
   *
   * @param b enables ({@code true}, or disables ({@code false} the generation of HTML format reports.
   */
  public void setHtmlFormat(boolean b) {
    if (b) {
      outputFormats.add(HTML_FORMAT);
    } else {
      outputFormats.remove(HTML_FORMAT);
    }
  }

  public void setProjectFileName(String projectFileName) {
    this.projectFileName = projectFileName;
    this.keepProjectFile = true;
  }

  public void setKeepProjectFile(boolean keepProjectFile) {
    this.keepProjectFile = keepProjectFile;
  }

  @Override
  public void execute() throws BuildException {
    ensureConfigurationIsValid();


    File projectFile = new File(projectFileName);

    if (!projectFile.exists()) {
      File parent = projectFile.getParentFile();

      if (!parent.exists() && !parent.mkdirs()) {
        throw new BuildException("Cannot create directory for project file " + parent.getAbsolutePath() + ".");
      }
    }

    try {
      writeProjectFile(projectFile, asXml());

      Java java = new Java(this);
      java.setClassname(MutationCoverageReport.class.getName());

      // if the user has specified any jvm arguments to use, then add it
      if (jvmArgs != null && jvmArgs.length() > 0) {
        java.createJvmarg().setLine(jvmArgs);
      }

      // if the user has specified any additional classpath information to the task, then add it
      if (classpath != null) {
        Path path = java.createClasspath();
        path.add(classpath.getPath());
      }

      Commandline.Argument arg = java.createArg();
      arg.setLine(ConfigOption.PROJECT_FILE.getParamName() + " " + projectFile.getCanonicalPath());

      java.executeJava();

    } catch (IOException e) {
      throw new BuildException(e);
    } finally {
      if (!keepProjectFile) {
        if (!projectFile.delete()) {
          s_log.log(Level.WARNING, "Could not delete project file " + projectFileName + ".");
        }
      }
    }
  }

  private void ensureConfigurationIsValid() {
    // this will check that the minimum required values have been set.
    List<String> requiredValues = validateRequiredValues();

    // if there are required values missing, build a prettily formated string to present to the user...
    if (requiredValues.size() > 0) {
      StringBuffer buffo = new StringBuffer();
      String sep = "";

      for (String s : requiredValues) {
        buffo.append(sep);
        buffo.append(s);

        sep = ", ";
      }

      throw new BuildException("Cannot process task. " + buffo.toString() + " values must be specified.");
    }
  }

  private void writeProjectFile(File projectFile, String contents) {
    PrintWriter pw = null;

    try {
      pw = new PrintWriter(projectFile);
      pw.println(contents);
    } catch (FileNotFoundException e) {
      throw new BuildException(e);
    } finally {
      if (pw != null) {
        pw.close();
      }
    }
  }

  protected String asXml() {
    StringOutputStream sos = new StringOutputStream();
    PrintWriter pw = null;

    try {
      pw = new PrintWriter(sos);

      pw.println("<project>");
      configureReportDirectory(pw);
      configureReportFormats(pw);

      configureClasspath(pw);
      configureExcludedClasses(pw);
      configureExcludedMethods(pw);
      configureInScopeClasses(pw);
      configureSourceDirectories(pw);
      configureTargetClasses(pw);
      configureTargetTests(pw);

      appendProperty(pw, ConfigOption.DEPENDENCY_DISTANCE, Integer.toString(dependencyDistance));
      appendProperty(pw, ConfigOption.MUTATE_STATIC_INITIALIZERS, Boolean.toString(mutateStaticInitializers));
      appendProperty(pw, ConfigOption.THREADS, Integer.toString(threads));
      appendProperty(pw, ConfigOption.TIMEOUT_CONST, Long.toString(timeoutConst));
      appendProperty(pw, ConfigOption.TIMEOUT_FACTOR, Float.toString(timeoutFactor));
      appendProperty(pw, ConfigOption.MAX_MUTATIONS_PER_CLASS, Integer.toString(maxMutationsPerClass));

      pw.println("</project>");

    } finally {
      if (pw != null) {
        pw.close();
      }
    }

    return sos.toString();
  }

  /**
   * Performs any validation of values that are required to be specified. If the values are invalid then the name of the
   * configuration option will be added to a {@see List} and returned.
   *
   * @return a {@see List} of {@see ConfigOption#paramName} values that are invalid.
   */
  protected List<String> validateRequiredValues() {
    List<String> errors = new ArrayList<String>();

    if (reportDir == null) {
      errors.add(ConfigOption.REPORT_DIR.getParamName());
    }

    if (sourceDirectories == null) {
      errors.add(ConfigOption.SOURCE_DIR.getParamName());
    }

    if (targetClasses == null) {
      errors.add(ConfigOption.TARGET_CLASSES.getParamName());
    }

    return errors;
  }

  private void configureClasspath(PrintWriter pw) {
    if (classpath == null) {
      return;
    }

    if (classpath.size() == 0) {
      return;
    }

    pw.println("<" + ConfigOption.CLASSPATH.getParamName() + ">");

    for (File f : PathUtils.convertPath(classpath.getPath())) {
      try {
        if (f.isDirectory()) {
          pw.println("<dir name=\"" + f.getCanonicalPath() + "\"/>");
        } else {
          // if it's not a directory then it's a file, double check it is a jar file.
          if (f.getName().endsWith(".jar")) {
            pw.println("<jar name=\"" + f.getCanonicalPath() + "\"/>");
          } else {
            if (s_log.isLoggable(Level.WARNING)) {
              s_log.log(Level.WARNING, "Cannot add " + f.getAbsolutePath() + " as a classpath element, it is not a directory or the filename does not end in .jar");
            }
          }
        }
      } catch (IOException e) {
        if (s_log.isLoggable(Level.WARNING)) {
          s_log.log(Level.WARNING, "Cannot add classpath element " + f.getAbsolutePath() + ".", e);
        }
      }
    }

    pw.println("</" + ConfigOption.CLASSPATH.getParamName() + ">");
  }

  private void configureReportDirectory(PrintWriter pw) {
    try {
      File outputDirectory = validateDirectoryForWrite(reportDir);

      appendProperty(pw, ConfigOption.REPORT_DIR, outputDirectory.getCanonicalPath());
    } catch (IOException e) {
      throw new BuildException(e);
    }
  }

  private void configureTargetClasses(PrintWriter pw) {
    if (targetClasses == null) {
      return;
    }

    if (targetClasses.size() == 0) {
      return;
    }

    appendFilter(pw, ConfigOption.TARGET_CLASSES, targetClasses.asList());
  }

  private void configureTargetTests(PrintWriter pw) {
    if (targetTests == null) {
      return;
    }

    if (targetTests.size() == 0) {
      return;
    }

    appendFilter(pw, ConfigOption.TEST_FILTER, targetTests.asList());
  }

  private void configureSourceDirectories(PrintWriter pw) {
    if (sourceDirectories == null) {
      return;
    }

    if (sourceDirectories.size() == 0) {
      return;
    }

    pw.println("<" + ConfigOption.SOURCE_DIR.getParamName() + ">");
    for (String s : sourceDirectories.asList()) {
      pw.println("<dir name=\"" + s + "\"/>");
    }
    pw.println("</" + ConfigOption.SOURCE_DIR.getParamName() + ">");
  }

  private void configureInScopeClasses(PrintWriter pw) {
    if (inScopeClasses == null) {
      return;
    }

    if (inScopeClasses.size() == 0) {
      return;
    }

    appendFilter(pw, ConfigOption.IN_SCOPE_CLASSES, inScopeClasses.asList());
  }

  private void configureExcludedClasses(PrintWriter pw) {
    if (excludedClasses == null) {
      return;
    }

    if (excludedClasses.size() == 0) {
      return;
    }

    appendFilter(pw, ConfigOption.EXCLUDED_CLASSES, excludedClasses.asList());
  }

  private void configureExcludedMethods(PrintWriter pw) {
    if (excludedMethods == null) {
      return;
    }

    if (excludedMethods.size() == 0) {
      return;
    }

    appendFilter(pw, ConfigOption.EXCLUDED_METHOD, excludedMethods.asList());
  }

  private void configureReportFormats(PrintWriter pw) {
    // if the output formats haven't been specified, assume that we can take the HTML report by default.
    if (outputFormats.isEmpty()) {
      outputFormats.add(HTML_FORMAT);
    }

    for (final String s : outputFormats) {
      pw.println("<" + ConfigOption.OUTPUT_FORMATS.getParamName() + " type=\"" + s + "\"/>");
    }
  }


  public TargetTests createTargetTests() {
    TargetTests tc = new TargetTests(this);
    targetTests = tc;

    return tc;
  }

  public TargetClasses createTargetClasses() {
    TargetClasses tc = new TargetClasses(this);
    targetClasses = tc;

    return tc;
  }

  public InScopeClasses createInScopeClasses() {
    InScopeClasses isc = new InScopeClasses(this);
    inScopeClasses = isc;

    return isc;
  }

  public ExcludedMethods createExcludedMethods() {
    ExcludedMethods m = new ExcludedMethods(this);
    excludedMethods = m;

    return m;
  }

  public ExcludedClasses createExcludedClasses() {
    ExcludedClasses c = new ExcludedClasses(this);
    excludedClasses = c;

    return c;
  }

  public Classpath createClasspath() {
    Classpath cp = new Classpath(this);
    classpath = cp;

    return cp;
  }

  public SourceDirs createSourceDirs() {
    SourceDirs sd = new SourceDirs(this);
    sourceDirectories = sd;

    return sd;
  }
}
